✨️ server: add activity and push notification on card decline#622
✨️ server: add activity and push notification on card decline#622
Conversation
🦋 Changeset detectedLatest commit: 2e193da The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds server-side decline handling: classifies decline reasons, persists declined transaction bodies, routes frozen/non-active/error flows through new decline handlers, triggers push notifications for select decline types, extends activity schema to include "requested"/declined fields, and adds tests for declines and notifications. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant CardSystem as Card System
participant PandaHook as Panda Hook
participant DB as Database
participant Notif as Notification Service
Client->>CardSystem: submit transaction
CardSystem->>PandaHook: webhook / payload
PandaHook->>DB: fetch card & transaction
alt card FROZEN or not ACTIVE
DB-->>PandaHook: card status != ACTIVE
PandaHook->>PandaHook: getDeclineReason()
PandaHook->>PandaHook: handleRejectedTransactionSync()
PandaHook-->>CardSystem: 403 / decline response
else processing error or explicit decline
PandaHook->>PandaHook: getDeclineReason()
PandaHook->>DB: updateTransactionRecord(...) with declined body
DB-->>PandaHook: OK
PandaHook->>PandaHook: handleDeclinedTransaction()
PandaHook->>Notif: sendDeclinedNotification()
Notif-->>Client: push notification delivered
else success
PandaHook-->>CardSystem: success response
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello @aguxez, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request enhances the server's transaction handling by introducing a mechanism to process declined card transactions. The immediate user-facing change is the implementation of push notifications, which will inform users instantly about rejected purchases. Although the foundational code for recording these declined transactions as user activity has been added, this specific database logging functionality is temporarily disabled, pending the development of corresponding user interface elements. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In @server/hooks/panda.ts:
- Around line 955-998: Remove the large commented-out DB logic inside
handleDeclinedTransaction and track the work in your issue tracker: create an
issue describing the pending UI changes needed to handle declined transactions
and include its ID; then replace the commented block with a single-line comment
in handleDeclinedTransaction referencing that issue (e.g., "See ISSUE-1234:
enable declined-transaction persistence once UI supports it"). Ensure the rest
of the function (push notification and error capture) remains unchanged.
- Line 965: Replace the existing comment "// TODO: Enable once UI has proper
designs to handle declined transactions in activity" with the coding-guideline
compliant format: use uppercase tag, a single space, and a fully lowercase
comment body (e.g. "// TODO enable once ui has proper designs to handle declined
transactions in activity"); update the line containing that TODO comment in the
server/hooks/panda.ts hook to remove the colon and capitalize only the TODO tag.
📜 Review details
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
📒 Files selected for processing (3)
.changeset/honest-peas-stand.mdserver/hooks/panda.tsserver/test/hooks/panda.test.ts
🧰 Additional context used
📓 Path-based instructions (7)
**/.changeset/*.md
📄 CodeRabbit inference engine (.cursor/rules/style.mdc)
Use a lowercase sentence in the imperative present tense for changeset summaries
Files:
.changeset/honest-peas-stand.md
server/**/*.ts
📄 CodeRabbit inference engine (.cursor/rules/server.mdc)
server/**/*.ts: Usec.varobject to pass strongly-typed data between Hono middleware and route handlers; do not usec.set
All request validation (headers, body, params) must be handled by@hono/valibot-validatormiddleware; do not perform manual validation inside route handlers
Use Hono's built-in error handling by throwingnew HTTPException()for expected errors; unhandled errors will be caught and logged automatically
Enforce Node.js best practices using ESLintplugin:n/recommendedconfiguration
Enforce Drizzle ORM best practices using ESLintplugin:drizzle/allconfiguration, including requiringwhereclauses forupdateanddeleteoperations
Use Drizzle ORM query builder for all database interactions; do not write raw SQL queries unless absolutely unavoidable
All authentication and authorization logic must be implemented in Hono middleware
Do not accessprocess.envdirectly in application code; load all configuration and secrets once at startup and pass them through dependency injection or context
Avoid long-running, synchronous operations; useasync/awaitcorrectly and be mindful of CPU-intensive tasks to prevent blocking the event loop
Files:
server/hooks/panda.tsserver/test/hooks/panda.test.ts
**/*.{js,ts,tsx,jsx,sol}
📄 CodeRabbit inference engine (AGENTS.md)
Follow linter/formatter (eslint, prettier, solhint) strictly with high strictness level. No
anytype.
Files:
server/hooks/panda.tsserver/test/hooks/panda.test.ts
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
Omit redundant type names in variable declarations - let the type system explain itself
**/*.{ts,tsx}: Use PascalCase for TypeScript types and interfaces
Use valibot for all runtime validation of API inputs, environment variables, and other data; define schemas once and reuse them
Infer TypeScript types from valibot schemas usingtype User = v.Input<typeof UserSchema>instead of manually defining interfaces
Files:
server/hooks/panda.tsserver/test/hooks/panda.test.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Omit contextual names - don't repeat class/module names in members
Omit meaningless words like 'data', 'state', 'manager', 'engine', 'value' from variable and function names unless they add disambiguation
**/*.{ts,tsx,js,jsx}: Prefer function declarations for all multi-line functions; use function expressions or arrow functions only for single-line implementations
Preferconstfor all variable declarations by default; only useletif the variable's value will be reassigned
Declare each variable on its own line with its ownconstorletkeyword, not multiple declarations on one line
Use camelCase for TypeScript variables and functions
Always useimport type { ... }for type imports
Use relative paths for all imports within the project; avoid tsconfig path aliases
Follow eslint-plugin-import order: react, external libraries, then relative paths
Use object and array destructuring to access and use properties
Use object method shorthand syntax when a function is a property of an object
Prefer optional chaining (?.), nullish coalescing (??), object and array spreading (...), andfor...ofloops over traditional syntax
Do not use abbreviations or cryptic names; write out full words likeerror,parameters,requestinstead oferr,params,req
UseNumber.parseInt()instead of the globalparseInt()function when parsing numbers
All classes called withnewmust use PascalCase
UseBuffer.from(),Buffer.alloc(), orBuffer.allocUnsafe()instead of the deprecatednew Buffer()
Use@ts-expect-errorinstead of@ts-ignore; follow it immediately with a single-line lowercase comment explaining why the error is expected, without separators like-or:
Do not include the type in a variable's name; let the static type system do its job (e.g., useconst user: Usernotconst userObject: User)
Do not repeat the name of a class or module within its members; omit contextual names (e.g., use `class User { getProfil...
Files:
server/hooks/panda.tsserver/test/hooks/panda.test.ts
server/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
server/**/*.{ts,tsx}: Server API: implement schema-first approach using OpenAPI via hono with validation via valibot middleware
Server database: drizzle schema is source of truth. Migrations required. No direct database access in handlers - usec.var.db
Files:
server/hooks/panda.tsserver/test/hooks/panda.test.ts
**/*.ts
📄 CodeRabbit inference engine (.cursor/rules/style.mdc)
For files with a single
defaultexport, name the file identically to the export; for files with multiple exports, use camelCase with a strong preference for a single word
Files:
server/hooks/panda.tsserver/test/hooks/panda.test.ts
🧠 Learnings (2)
📚 Learning: 2025-12-31T00:23:55.034Z
Learnt from: cruzdanilo
Repo: exactly/exa PR: 610
File: .changeset/ready-experts-fly.md:1-2
Timestamp: 2025-12-31T00:23:55.034Z
Learning: In the exactly/exa repository, allow and require empty changeset files (containing only --- separators) when changes are not user-facing and do not warrant a version bump. This is needed because CI runs changeset status --since origin/main and requires a changeset file to exist. Ensure such empty changesets are used only for non-user-facing changes and document the rationale in the commit or changelog notes.
Applied to files:
.changeset/honest-peas-stand.md
📚 Learning: 2025-12-23T19:58:16.574Z
Learnt from: CR
Repo: exactly/exa PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-23T19:58:16.574Z
Learning: Zero config local dev environment: no `.env` files, mock all external services
Applied to files:
server/test/hooks/panda.test.ts
🧬 Code graph analysis (2)
server/hooks/panda.ts (1)
server/utils/onesignal.ts (1)
sendPushNotification(7-25)
server/test/hooks/panda.test.ts (1)
server/database/schema.ts (1)
transactions(36-43)
🔇 Additional comments (5)
server/test/hooks/panda.test.ts (2)
2-2: LGTM!Import adjustments for mocks are clean and improve organization.
Also applies to: 7-7
1352-1439: Test stubs properly scaffolded for future implementation.The two test cases for declined transaction handling are well-structured and align with the commented-out database logic in
server/hooks/panda.ts. Usingit.todois appropriate while waiting for UI designs.server/hooks/panda.ts (2)
533-533: LGTM!The call to
handleDeclinedTransactionis correctly placed after the mutex is released, and the type cast is necessary due to TypeScript's union type handling.
988-998: LGTM!Push notification implementation properly formats the transaction details and includes error handling consistent with the rest of the codebase.
.changeset/honest-peas-stand.md (1)
1-5: LGTM!Changeset properly documents the feature addition with appropriate version bump and clear description.
118a340 to
dc0ac8a
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #622 +/- ##
==========================================
+ Coverage 71.23% 72.08% +0.85%
==========================================
Files 212 212
Lines 8378 8655 +277
Branches 2741 2876 +135
==========================================
+ Hits 5968 6239 +271
- Misses 2132 2140 +8
+ Partials 278 276 -2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
2b6c4a8 to
64325f3
Compare
64325f3 to
b3814d0
Compare
b3814d0 to
8c11c8c
Compare
8c11c8c to
77c26c2
Compare
98e690f to
a16d92d
Compare
a16d92d to
685fbf3
Compare
685fbf3 to
4bcf82f
Compare
4bcf82f to
bbacd86
Compare
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
| if (card.status !== "ACTIVE") { | ||
| trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "card-not-active"); | ||
| return c.json({ code: "card not active" }, 403); |
There was a problem hiding this comment.
🟡 Missing reject call for non-ACTIVE, non-FROZEN card status leaves declined transaction unrecorded
At server/hooks/panda.ts:271-273, when the card status is neither ACTIVE nor FROZEN (e.g., DELETED), the handler tracks the rejection and returns 403, but does not call reject() to record the declined transaction in the database. This is inconsistent with the FROZEN path at line 263-268 which does call reject(). As a result, Panda will receive a 403 and send back a "created" webhook with status: "declined", but there will be no corresponding "requested" decline record in the DB. While the eventual "created" decline will be recorded, the reason context from the original request rejection is lost.
| if (card.status !== "ACTIVE") { | |
| trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "card-not-active"); | |
| return c.json({ code: "card not active" }, 403); | |
| if (card.status !== "ACTIVE") { | |
| trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "card-not-active"); | |
| await reject(account, payload, jsonBody, "card not active"); | |
| return c.json({ code: "card not active" }, 403); | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
🔴 PandaActivity throws "invalid flow" for records containing only "requested" bodies
When reject() is called during the "requested" webhook handler (e.g., frozen card at server/hooks/panda.ts:266, InsufficientAccountLiquidity at server/hooks/panda.ts:457), it stores a transaction record with a single body having action: "requested". When the activity API later processes this record through PandaActivity, the flow reducer at server/api/activity.ts:600-601 skips "requested" operations (return f), leaving both flow.created and flow.completed as undefined. This causes details at line 610 to be undefined, and line 611 throws "invalid flow". This throw inside the valibot transform can crash the entire activity API request for the user.
Race condition timeline
- "requested" webhook arrives →
reject()stores a record with one body (action: "requested",status: "declined") - User queries the activity API →
PandaActivitytransform throws"invalid flow"because no "created" or "completed" operation exists in the flow - Panda sends "created" webhook with
status: "declined"→reject()appends a second body → record is now valid
The window between steps 1 and 3 is the bug trigger. If the "created" follow-up webhook never arrives (or arrives to a different server instance), the record is permanently broken.
(Refers to lines 610-611)
Prompt for agents
In server/api/activity.ts, at lines 610-611, the PandaActivity transform throws "invalid flow" when `flow.created` and `flow.completed` are both undefined. This happens when the only operations have action "requested" (which are skipped by the flow reducer at line 600-601). Fix this by handling the declined-only case. When `details` is undefined but `declined` is defined, use the `declined` operation as the details source instead of throwing. For example:
const details = flow.created ?? flow.completed ?? declined;
if (!details) throw new Error("invalid flow");
This ensures records that contain only "requested" declined bodies can be processed correctly without throwing.
Was this helpful? React with 👍 or 👎 to provide feedback.
| declineReason: string, | ||
| ) { | ||
| const { spend } = payload.body; | ||
| const transactionId = payload.body.id ?? payload.id; |
There was a problem hiding this comment.
🟡 reject() uses webhook event ID as transaction primary key when payload.body.id is undefined
In reject() at server/hooks/panda.ts:1236, transactionId is computed as payload.body.id ?? payload.id. For "requested" action payloads, payload.body.id is optional (server/hooks/panda.ts:127: v.optional(v.string())). When body.id is undefined, the webhook event ID (payload.id) is used as the transaction record's primary key. A subsequent "created" webhook for the same card transaction uses the actual transaction body ID, so reject() would insert a new record (different primary key) instead of appending via onConflictDoUpdate. This creates an orphaned declined record that is disconnected from the actual transaction flow.
Was this helpful? React with 👍 or 👎 to provide feedback.
| }), | ||
| ] | ||
| .filter(<T>(value: T | undefined): value is T => value !== undefined) | ||
| .filter((item) => chain.id === anvil.id || !("status" in item && item.status === "declined")) |
There was a problem hiding this comment.
🚩 Declined filter hides items in production but not test — intentional but fragile
The new filter at server/api/activity.ts:431 uses chain.id === anvil.id to show declined transactions only in the anvil (test) environment. In production, all declined items are silently removed from the response. This is presumably a temporary measure while client-side support for declined transactions is being built. However, this couples the API behavior to the chain ID, meaning staging environments on non-anvil chains would also hide declined items. Consider using a feature flag or explicit query parameter instead of chain ID for more predictable behavior.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (card.status === "FROZEN") { | ||
| trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "frozen-card"); | ||
|
|
||
| await reject(account, payload, jsonBody, "frozen_card"); | ||
|
|
||
| return c.json({ code: "frozen card" }, 403 as UnofficialStatusCode); | ||
| } | ||
|
|
||
| if (card.status !== "ACTIVE") { | ||
| trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "card-not-active"); | ||
| return c.json({ code: "card not active" }, 403); |
There was a problem hiding this comment.
🚩 Card status query change removes ACTIVE filter — broadens initial query
At server/hooks/panda.ts:254-255, the card query was changed from where: and(eq(cards.id, ...), eq(cards.status, 'ACTIVE')) to just where: eq(cards.id, ...), with status checks moved to application code (lines 263-273). This is needed to support the frozen card rejection flow. However, the card.status !== 'ACTIVE' check at line 271 returns 403 without calling reject(), unlike the FROZEN path at line 263-268 which does call reject(). This means cards in DELETED state (the third enum value per server/database/schema.ts:22) will return 403 but won't store a declined transaction record, creating an asymmetry in decline tracking.
Was this helpful? React with 👍 or 👎 to provide feedback.
| type, | ||
| settled: !!flow.completed, | ||
| usdAmount, | ||
| status: declined ? ("declined" as const) : flow.completed ? ("settled" as const) : ("pending" as const), |
There was a problem hiding this comment.
🚩 Breaking change: settled boolean replaced with status string enum
The PandaActivity output at server/api/activity.ts:639 replaces settled: boolean with status: 'declined' | 'settled' | 'pending'. This is a breaking change for any API consumers that rely on the settled field. Since this is a patch changeset (.changeset/yummy-lands-end.md), existing clients consuming this API may break if they check for settled instead of status. If there are mobile app versions in the wild that read settled, they would stop receiving that field.
Was this helpful? React with 👍 or 👎 to provide feedback.
| timestamp, | ||
| merchant: { city, country, name, state }, |
There was a problem hiding this comment.
Bug: The PandaActivity transformer ignores "requested" actions, causing an "invalid flow" error for frozen card declines, which are then silently dropped from the user's activity feed.
Severity: HIGH
Suggested Fix
Modify the flow reducer in server/api/activity.ts to handle "requested" actions for declined transactions. Instead of ignoring them, the reducer should populate the flow object appropriately, perhaps by treating the "requested" action as a completed action when its status is "declined". This will prevent the "invalid flow" error and allow the declined transaction to be displayed in the activity feed.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: server/api/activity.ts#L616-L617
Potential issue: When a card is frozen, a webhook with a `"requested"` action is
processed. The `PandaActivity` transformer's flow reducer at
`server/api/activity.ts:600-601` explicitly ignores `"requested"` actions (`case
"requested": return f;`). Consequently, the `flow` object has no `created` or
`completed` properties. This causes the code at lines 614-616 to throw an "invalid flow"
error. The error is caught, but the transaction is silently dropped and never appears in
the user's activity feed, defeating the purpose of the PR.
closes #114
Summary by CodeRabbit
New Features
Bug Fixes
Tests
Chores
Greptile Summary
This PR wires up activity-feed entries and push notifications for declined card transactions. When Panda delivers a
requestedwebhook for a frozen/inactive card, or when acreated/updatedwebhook carriesstatus: "declined", the newreject()helper inserts (or appends to) a transaction record with a zero-hash placeholder and sends a targeted push notification for recognized decline reasons (insufficient_funds,merchant_blocked,frozen_card). ThePandaActivityschema is extended to parse these records on the activity-feed API side.Key changes:
reject()function inpanda.tsuses PostgreSQLjsonb_setto append declined bodies to existing transaction rows and gates push notifications viaxmax = 0(first-insert detection).PandaActivitytransformer normalizes"requested"to"created"and spreadsstatus/reasononto the output when a declined body is found; thesettledfield is removed (no consumers found in the codebase).trackAuthorizationRejectedcalls in therequestedhandler; only the frozen-card path callsreject().captureExceptioncontext for bad transactions is improved by flattening valibot issues instead of passing raw parse objects.Issues found:
mapped.find(b => b.status === "declined")picks the first declined body, so when multiple declined entries accumulate for the same transaction (e.g., acreateddecline followed by anupdateddecline with a different reason), the stale earlier reason is shown to the user.findLastwould show the most recent.reject()is called in the generic unexpected-error path (line 469) regardless of whether the error was issuing-network related. Any unhandled server error will produce areason: "transaction declined"activity record that is indistinguishable from a real network decline to the user, though no push notification is sent.Confidence Score: 3/5
find()instead offindLast()will surface stale decline reasons when multiple declines accumulate for a transaction, and (2) unexpected server errors now create permanent "transaction declined" activity records indistinguishable from real declines to users. Neither causes data loss or security issues, but both degrade UX and observability. The issues are straightforward to fix and should be addressed before or immediately after merge.Sequence Diagram
sequenceDiagram participant Panda participant Hook as panda.ts hook participant DB as transactions DB participant Push as Push Notification Panda->>Hook: POST requested (card spend attempt) Hook->>DB: query card by id (fetch status) alt card FROZEN Hook->>DB: reject() → INSERT/APPEND declined body (frozen_card) Hook->>Push: sendDeclinedNotification (if isNew) Hook-->>Panda: 403 frozen card else card not ACTIVE Hook-->>Panda: 403 card not active (no reject() call) else card ACTIVE Hook->>Hook: risk assess + prepareCollection alt PandaError (e.g. InsufficientAccountLiquidity) Hook->>DB: reject() → INSERT/APPEND declined body Hook->>Push: sendDeclinedNotification (if isNew & notify) Hook-->>Panda: 557 error code else unexpected error Hook->>DB: reject() → INSERT/APPEND declined body (reason: "transaction declined") Hook-->>Panda: 569 ouch else success Hook-->>Panda: 200 ok end end Panda->>Hook: POST created/updated (status: declined) Hook->>DB: await reject() → INSERT/APPEND declined body Hook->>Push: sendDeclinedNotification (if isNew & notify) Hook-->>Panda: 200 ok note over DB,Push: isNew = (xmax = 0) — only first insert fires notificationLast reviewed commit: dd260a8